深入 React Hooks 工作机制|原理篇
30 秒速记
- 核心判断:
Hooks用稳定调用顺序把函数组件的多次执行与内部状态槽位对应起来 - 原理主线:围绕 「从源码调用流程看原理:Hooks 的正常」、「站在底层视角,重现 PersonalIn」、「核心机制」 建立输入、状态变化与输出之间的因果关系
- 文章范围:解析了 React Hooks 的工作原理、使用原则及其背后的机制,帮助开发者理解如何正确使用 Hooks 以及避免常见的使用误区。
- 边界与代价:条件调用会破坏槽位映射,闭包会保留某次渲染快照;严格模式重跑用于暴露副作用问题
- 工程落地:工程上把依赖数组当作数据流声明,优先消除不必要副作用并保证清理可重复执行
Hooks 依靠固定的调用顺序,把函数组件每次执行时的调用位置与对应状态关联起来。 首次渲染时,mountState 会创建 Hook 对象并按顺序组成单向链表;更新时,updateState 再沿着已有链表依次读取状态。React 不会根据 name、career 这类变量名匹配状态,只认调用位置,所以在条件语句中跳过某个 Hook,后续状态就可能错位,甚至触发“调用数量少于预期”的报错。工程上应只在 React 函数顶层调用 Hook,不要放进条件、循环或嵌套函数。
这篇文章不要按 API 清单来背。先用上面的 Mind Map 建立全局结构,再通过交互 DEMO 观察正常路径和边界路径如何改变状态;阅读正文时重点核对每一步的输入、负责执行的参与者、产生的中间状态以及最终可观察结果。遇到版本敏感结论,要把“历史实现”“当前行为”和“工程兼容策略”分开说明;遇到性能或架构取舍,则用实际指标、失败现象和验证手段支撑判断。
版本校准: 本文若分析 ReactDOM.render、旧生命周期或栈调和,应把它视为理解架构演进的历史路径。React 19 已移除 ReactDOM.render,当前客户端入口使用 createRoot;并发渲染也必须区分可中断的渲染阶段与同步提交阶段。迁移前对照 React 19 官方升级指南。
React 团队面向开发者给出了两条 React-Hooks 的使用原则,原则的内容如下:
- 只在 React 函数中调用
Hook; - 不要在循环、条件或嵌套函数中调用
Hook。
从现象看问题:若不保证 Hooks 执行顺序,会带来什么麻烦
先来看一个小 Demo:
import React, { useState } from "react";
function PersonalInfoComponent() {
// 集中定义变量
let name, age, career, setName, setCareer;
// 获取姓名状态
[name, setName] = useState("修言");
// 获取年龄状态
[age] = useState("99");
// 获取职业状态
[career, setCareer] = useState("我是一个前端,爱吃小熊饼干");
// 输出职业信息
console.log("career", career);
// 编写 UI 逻辑
return (
<div className="personalInfo">
<p>姓名:{name}</p>
<p>年龄:{age}</p>
<p>职业:{career}</p>
<button
onClick={() => {
setName("秀妍");
}}
>
修改姓名
</button>
</div>
);
}
export default PersonalInfoComponent;
这个 PersonalInfoComponent 组件渲染出来的界面长这样:

PersonalInfoComponent 用于对个人信息进行展示,这里展示的内容包括姓名、年龄、职业。出于测试效果需要,PersonalInfoComponent 还允许你点击“修改姓名”按钮修改姓名信息。点击一次后,“修言”会被修改为“秀妍”,如下图所示:

到目前为止,组件的行为都是符合我们的预期的,一切看上去都是那么的和谐。但倘若我对代码做一丝小小的改变,把一部分的 useState 操作放进 if 语句里,事情就会变得大不一样。改动后的代码如下:
import React, { useState } from "react";
// isMounted 用于记录是否已挂载(是否是首次渲染)
let isMounted = false;
function PersonalInfoComponent() {
// 定义变量的逻辑不变
let name, age, career, setName, setCareer;
// 这里追加对 isMounted 的输出,这是一个 debug 性质的操作
console.log("isMounted is", isMounted);
// 这里追加 if 逻辑:只有在首次渲染(组件还未挂载)时,才获取 name、age 两个状态
if (!isMounted) {
// eslint-disable-next-line
[name, setName] = useState("修言");
// eslint-disable-next-line
[age] = useState("99");
// if 内部的逻辑执行一次后,就将 isMounted 置为 true(说明已挂载,后续都不再是首次渲染了)
isMounted = true;
}
// 对职业信息的获取逻辑不变
[career, setCareer] = useState("我是一个前端,爱吃小熊饼干");
// 这里追加对 career 的输出,这也是一个 debug 性质的操作
console.log("career", career);
// UI 逻辑的改动在于,name和age成了可选的展示项,若值为空,则不展示
return (
<div className="personalInfo">
{name ? <p>姓名:{name}</p> : null}
{age ? <p>年龄:{age}</p> : null}
<p>职业:{career}</p>
<button
onClick={() => {
setName("秀妍");
}}
>
修改姓名
</button>
</div>
);
}
export default PersonalInfoComponent;
修改后的组件在初始渲染的时候,界面与上个版本无异:

注意,你在自己电脑上模仿这段代码的时候,千万不要漏掉 if 语句里面// eslint-disable-next-line这个注释——因为目前大部分的 React 项目都在内部预置了对 React-Hooks-Rule(React-Hooks 使用规则)的强校验,而示例代码中把 Hooks 放进 if 语句的操作作为一种不合规操作,会被直接识别为 Error 级别的错误,进而导致程序报错。这里我们只有将相关代码的 eslint 校验给禁用掉,才能够避免校验性质的报错,从而更直观地看到错误的效果到底是什么样的,进而理解错误的原因。
修改后的组件在初始挂载的时候,实际执行的逻辑内容和上个版本是没有区别的,都涉及对 name、age、career 三个状态的获取和渲染。理论上来说,变化应该发生在我单击“修改姓名”之后触发的二次渲染里:二次渲染时,isMounted 已经被置为 true,if 内部的逻辑会被直接跳过。此时按照代码注释中给出的设计意图,这里我希望在二次渲染时,只获取并展示 career 这一个状态。那么事情是否会如我所愿呢?我们一起来看看单击“修改姓名”按钮后会发生什么:

组件不仅没有像预期中一样发生界面变化,甚至直接报错了。报错信息提醒我们,这是因为“组件渲染的 Hooks 比期望中更少”
确实,按照现有的逻辑,初始渲染调用了三次 useState,而二次渲染时只会调用一次。但仅仅因为这个,就要报错吗?
按道理来说,二次渲染的时候,只要我获取到的 career 值没有问题,那么渲染就应该是没有问题的(因为二次渲染实际只会渲染 career 这一个状态),React 就没有理由阻止我的渲染动作。啊这……难道是 career 出问题了吗?还好我们预先留了一手 Debug 逻辑,每次渲染的时候都会尝试去输出一次 isMounted 和 career 这两个变量的值。现在我们就赶紧来看看,这两个变量到底是什么情况。
首先我将界面重置回初次挂载的状态,观察控制台的输出,如下图所示:

这里我把关键的 isMounted 和 career 两个变量用红色框框圈了出来:isMounted 值为 false,说明是初次渲染;career 值为“我是一个前端,爱吃小熊饼干”,这也是没有问题的。
接下来单击“修改姓名”按钮后,我们再来看一眼两个变量的内容,如下图所示:

二次渲染时,isMounted 为 true,这个没毛病。但是 career 竟然被修改为了“秀妍”,这也太诡异了?代码里面可不是这么写的。赶紧回头确认一下按钮单击事件的回调内容,代码如下所示:
<button
onClick={() => {
setName("秀妍");
}}
>
修改姓名
</button>
确实,代码是没错的,我们调用的是 setName,那么它修改的状态也应该是 name,而不是 career。
那为什么最后发生变化的竟然是 career 呢?年轻人,不如我们一起来看一看 Hooks 的实现机制吧!
# 从源码调用流程看原理:Hooks 的正常运作,在底层依赖于顺序链表
这里强调“源码流程”而非“源码”,主要有两方面的考虑:
React-Hooks在源码层面和Fiber关联十分密切原理 !== 源码,阅读源码只是掌握原理的一种手段,在某些场景下,阅读源码确实能够迅速帮我们定位到问题的本质(比如React.createElement的源码就可以快速帮我们理解 JSX 转换出来的到底是什么东西);而 React-Hooks 的源码链路相对来说比较长,涉及的关键函数 renderWithHooks 中“脏逻辑”也比较多,整体来说,学习成本比较高,学习效果也难以保证。
以 useState 为例,分析 React-Hooks 的调用链路
首先要说明的是,React-Hooks 的调用链路在首次渲染和更新阶段是不同的,这里我将两个阶段的链路各总结进了两张大图里,我们依次来看。首先是首次渲染的过程,请看下图:

在这个流程中,useState 触发的一系列操作最后会落到 mountState 里面去,所以我们重点需要关注的就是 mountState 做了什么事情。以下我为你提取了 mountState 的源码:
